Skip to content

Index images by RepoDigests in LocalImagesCache, not just RepoTags - #11948

Open
itsmehotpants wants to merge 1 commit into
testcontainers:mainfrom
itsmehotpants:fix/1406-image-cache-repodigests
Open

Index images by RepoDigests in LocalImagesCache, not just RepoTags#11948
itsmehotpants wants to merge 1 commit into
testcontainers:mainfrom
itsmehotpants:fix/1406-image-cache-repodigests

Conversation

@itsmehotpants

Copy link
Copy Markdown

Fixes #1406.

LocalImagesCache.populateFromList() only indexed local images by their RepoTags. An image reachable only by digest (e.g. docker pull image@sha256:...) - or a tagged image where Docker reports RepoTags as null/["<none>:<none>"] after certain rebuild/retag sequences, while RepoDigests remains populated (see moby/moby#29157 for a concrete real-world case of this) - was never added to the cache. Testcontainers would then treat the image as absent locally and re-pull it every time, even though it was already present, defeating both the configured ImagePullPolicy and any lookup of an image by digest.

Change: populateFromList() now also reads Image::getRepoDigests() and indexes those digest-qualified names into the same cache, alongside RepoTags. An image with neither field populated is still skipped, as before (just with an updated log message reflecting the new condition).

Tests: added LocalImagesCacheTest covering a digest-only image, an image with both a tag and a digest, and the neither-present case. Uses the same ObjectMapper#convertValue(Map, Class) pattern already used in ReusabilityUnitTests for constructing docker-java model objects in tests, and the existing (previously unused) LocalImagesCacheAccessor test helper for cache isolation between tests.

Scope note: I did not additionally add Image ID-based lookup (mentioned in the issue's 2020 edit) since DockerImageName parsing doesn't cleanly represent a bare image ID (e.g. sha256:abcdef... without a repository) today - that would need its own design discussion, so I've kept this PR focused on the RepoDigests fix. Happy to look at Image ID support separately if maintainers want it.

LocalImagesCache.populateFromList() only indexed local images by their
RepoTags. An image referenced only by digest (e.g. after 'docker pull
image@sha256:...', or when Docker reports RepoTags as null/<none> for an
otherwise-tagged image, which happens after certain rebuild/retag
sequences) was never added to the cache. Testcontainers would then treat
the image as absent locally and re-pull it every time, even though it was
already present, defeating both the image pull policy and Ryuk/reaper
image lookups by digest.

populateFromList() now also reads Image::getRepoDigests() and indexes
those digest-qualified names into the same cache, alongside RepoTags. An
image with neither populated is still skipped, as before.

Added LocalImagesCacheTest covering: digest-only images, images with both
a tag and a digest, and the neither-present case. Uses the same
ObjectMapper#convertValue(Map, Class) pattern already used in
ReusabilityUnitTests for constructing docker-java model objects in tests,
and the existing (previously unused) LocalImagesCacheAccessor for cache
isolation between tests.

Fixes testcontainers#1406
@itsmehotpants
itsmehotpants requested a review from a team as a code owner July 26, 2026 17:47

@kdelay kdelay left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through this one and checked it against a live daemon, since the interesting part is what the daemon actually reports. Environment: Docker 28.1.1, containerd snapshotter image store (driver-type: io.containerd.snapshotter.v1), macOS/arm64, JDK 17, PR head 948d638b vs main 2ac3c977.

The fix does change real behavior, but not through the path the description describes.

I probed LocalImagesCache.INSTANCE.get(...) (public API, so the same code path AbstractImagePullPolicy uses) with three names, on both branches:

lookup main this PR
alpine@sha256:d9e853e8… (image pulled by digest only) hit hit
redis@sha256:09160599… (image pulled by tag, looked up by digest) miss hit
redis:alpine hit hit

Cache entries after init: 20 on main, 39 with the patch.

The first row is the scenario in the description, and it already hits on main. The reason is that this daemon puts the digest reference into RepoTags too:

RepoTags=[alpine@sha256:d9e853e8…]  RepoDigests=[alpine@sha256:d9e853e8…]

So the "RepoTags is null for a digest-pulled image" premise does not hold on Docker 28.1.1 with the containerd store. What does reproduce there is the second row: an image present locally under a tag, referenced by the user as repo@sha256:…, misses the cache today and gets re-pulled on every run under PULL_ONLY_MISSING. That is a real bug and this patch fixes it. It might be worth leading with that case in the description, both because a reviewer can reproduce it in two commands and because it doesn't depend on daemon version or image store.

Two smaller things, both measured, neither blocking:

  1. The new tests pin RepoTags == null. The other shape a daemon returns for the same situation is an empty array rather than null, and that shape isn't covered. I checked it works (Stream.of(new String[0]) is empty, putAll of an empty map is a no-op), so this is a coverage gap rather than a defect. A fourth case with RepoTags == [] and a populated RepoDigests would pin it.

  2. Dangling-image names now come in through a second field. I fed populateFromList a synthetic image with RepoTags=["<none>:<none>"], RepoDigests=["<none>@<none>"]: no exception, and the cache gains <none>@<none>:latest alongside the <none>:<none> key it already had on main (DockerImageName parses <none>@<none> as repository <none>@<none> plus the default latest tag). Harmless, and on my live daemon zero <none> keys appeared, so I'd only mention it in case you want to filter those names while you're in here.

On the test run: LocalImagesCacheTest is 3/3 green. Running the wider org.testcontainers.images.* + org.testcontainers.utility.* subset, I saw failures in AuthenticatedImagePullTest, ImagePullPolicyTest, OverrideImagePullPolicyTest and DockerfileBuildTest[4], and they are environmental here, not yours: the pull-policy ones pass standalone on your head (ImagePullPolicyTest 5/5, OverrideImagePullPolicyTest 2/2), and DockerfileBuildTest[4] fails identically on main standalone. The batch failures are registry lease errors from the containerd store (unable to lease content: lease does not exist) and vary run to run.

The @VisibleForTesting widening looks right to me, for what it's worth: maybeInitCache reaches for DockerClientFactory, so there is no way to unit-test this without either a daemon or that seam, and the class already exposes initialized and cache the same way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image cache lookup should support SHA256 digests and Image IDs

2 participants